라우트 기반 코드 스플리팅의 효과

라우트 기반 코드 스플리팅의 효과

한눈에 보기

코드 스플리팅의 목적은 파일 개수를 늘리는 것이 아니라 현재 화면에 필요하지 않은 JavaScript의 다운로드·파싱·실행을 미루는 것이다. 라우트는 사용자가 한 번에 방문하는 화면 단위라 자연스러운 분할점이 된다. 다만 chunk를 지나치게 쪼개면 요청 폭포와 로딩 UI가 늘 수 있으므로 초기 경로의 실행 비용, 이후 탐색 지연, 캐시 재사용을 함께 측정해야 한다.

관리자 페이지, 차트 편집기, Markdown 미리보기까지 포함된 React 애플리케이션을 생각해 보자. 사용자가 처음 보는 화면은 상품 목록인데, 단일 번들에 모든 기능이 들어 있다면 브라우저는 당장 쓰지 않을 관리자 코드까지 내려받아 해석한다.

네트워크 압축 크기가 줄어드는 것만으로 문제를 다 설명할 수는 없다. JavaScript는 다운로드 후 압축 해제, 파싱, 컴파일, 실행을 거친다. 특히 모바일 기기에서는 전송보다 메인 스레드 실행 비용이 더 크게 드러날 수 있다.

그렇다고 모든 컴포넌트를 lazy로 바꾸면 좋은 것도 아니다. 버튼을 누른 뒤 필요한 코드를 처음 발견하면 사용자는 네트워크 왕복을 기다려야 하고, 잘게 나뉜 chunk가 연쇄적으로 로드되면 요청 폭포가 생긴다.

코드 스플리팅은 “얼마나 많이 나눌까?”가 아니라 **어디에서 사용자 흐름을 끊지 않고 비용을 미룰까?**라는 경계 설계 문제다.

목차

JavaScript 한 파일이 만드는 숨은 비용

정적 import는 보통 모듈을 초기 번들 그래프에 포함시킨다.

import { ProductList } from "./product-list";
import { AdminDashboard } from "./admin-dashboard";
import { ReportChart } from "./report-chart";

export function App() {
  return <ProductList />;
}

현재 렌더에서 AdminDashboardReportChart를 사용하지 않더라도 import 그래프에 연결되어 있으면 번들러가 초기 자산에 포함할 수 있다. tree shaking이 사용하지 않는 export를 제거해 줄 수 있지만, 실제 라우터 조건과 부수 효과가 섞인 애플리케이션에서는 화면별 모듈 전체가 자동으로 사라진다고 기대하기 어렵다.

초기 JavaScript 비용은 대략 다음 단계로 나뉜다.

flowchart LR
    N["네트워크 다운로드"] --> D["압축 해제"]
    D --> P["파싱·컴파일"]
    P --> E["모듈 평가·실행"]
    E --> H["이벤트 연결·Hydration"]
    H --> I["상호작용 가능"]

파일의 gzip 크기만 보면 파싱과 실행 비용을 놓친다. 같은 100KB라도 단순한 데이터와 복잡한 초기화 로직은 메인 스레드 점유 시간이 다를 수 있다. 서드파티 SDK가 import 시점에 전역 이벤트를 연결하거나 큰 locale 데이터를 초기화한다면 사용하지 않는 화면에서도 비용이 발생한다.

다음 숫자는 원리를 설명하기 위한 가상의 예다.

구성 초기 압축 JS 초기 실행 시간 상품 목록에서 필요한 코드
단일 번들 620KB 540ms 190KB
라우트 분할 260KB 240ms 190KB
과도한 미세 분할 210KB 230ms 190KB

미세 분할은 초기 수치가 조금 더 좋지만 상품 목록을 사용하는 동안 작은 요청이 여러 번 이어질 수 있다. 최적의 지점은 숫자 하나가 아니라 핵심 사용자 흐름 전체에서 결정된다.

라우트가 좋은 분할 경계인 이유

라우트는 사용자가 동시에 보지 않는 기능을 이미 구분한다.

/
├── products            # 일반 사용자 상품 목록
├── products/:id        # 상품 상세
├── editor              # 무거운 에디터
└── admin
    ├── dashboard       # 관리자 차트
    └── users           # 사용자 관리

일반 사용자가 /products에 진입할 때 관리자 차트 라이브러리와 에디터를 미리 실행할 이유는 없다. 라우트별 chunk를 만들면 초기 경로에는 공통 shell과 상품 목록 코드만 포함하고, 다른 화면은 이동 시점에 불러올 수 있다.

flowchart TD
    C["공통 shell chunk"] --> P["products chunk"]
    C --> D["product detail chunk"]
    C --> E["editor chunk"]
    C --> A["admin chunk"]
    A --> CH["chart vendor chunk"]

라우트 분할의 장점은 다음과 같다.

많은 React 프레임워크는 파일 기반 라우트를 기준으로 자동 분할한다. 먼저 빌드 결과를 확인해야 하는 이유다. 이미 라우트 단위 분할이 되어 있는데 같은 페이지의 작은 컴포넌트를 무작정 수동 분할하면 이득보다 복잡성만 늘 수 있다.

React lazy와 Suspense로 기능 나누기

라우터나 프레임워크가 자동으로 나누지 않는 기능은 lazy와 동적 import()로 지연할 수 있다.

import { lazy, Suspense, useState } from "react";

const MarkdownPreview = lazy(
  () => import("./markdown-preview"),
);

export function Editor() {
  const [previewOpen, setPreviewOpen] = useState(false);

  return (
    <section>
      <button
        type="button"
        onClick={() => setPreviewOpen((open) => !open)}
      >
        미리보기
      </button>

      {previewOpen && (
        <Suspense fallback={<PreviewSkeleton />}>
          <MarkdownPreview />
        </Suspense>
      )}
    </section>
  );
}

MarkdownPreview를 처음 렌더하려 할 때 모듈 로딩이 시작된다. Promise가 해결될 때까지 가장 가까운 Suspense의 fallback이 보이고, 로드된 모듈과 Promise는 이후 사용을 위해 캐시된다.

lazy 선언은 컴포넌트 바깥, 모듈 최상위에 둔다.

// 좋지 않은 예
function Editor() {
  const Preview = lazy(() => import("./markdown-preview"));
  return <Preview />;
}

렌더할 때마다 새로운 컴포넌트 타입을 만들 수 있어 상태가 초기화되는 원인이 된다. 번들러와 프레임워크가 모듈을 미리 연결하기도 어려워진다.

React lazy가 읽는 모듈은 기본적으로 default export에 컴포넌트가 있어야 한다.

// markdown-preview.tsx
export default function MarkdownPreview() {
  return <article>미리보기</article>;
}

named export만 있는 모듈이라면 작은 adapter 모듈을 두거나 Promise 결과를 명시적으로 변환할 수 있다.

const MarkdownPreview = lazy(() =>
  import("./preview-components").then((module) => ({
    default: module.MarkdownPreview,
  })),
);

가능하면 lazy 경계가 가져오는 모듈의 공개 API를 단순하게 유지한다. 하나의 거대한 barrel 파일에서 named export 하나만 골라 오면 예상보다 많은 의존성이 chunk에 포함될 수 있다.

프레임워크의 자동 분할과 수동 분할 구분하기

Next.js App Router는 라우트를 기준으로 애플리케이션을 여러 chunk로 나눈다. 따라서 첫 질문은 “dynamic()을 어디에 추가할까?”가 아니라 “현재 라우트가 실제로 어떤 JavaScript를 보내고 있는가?”다.

수동 lazy loading이 유용한 경우는 보통 다음과 같다.

Next.js의 dynamic을 이용한 예시는 다음과 같다.

"use client";

import dynamic from "next/dynamic";

const AnalyticsChart = dynamic(
  () => import("./analytics-chart"),
  {
    loading: () => <ChartSkeleton />,
  },
);

export function AnalyticsPanel() {
  return <AnalyticsChart />;
}

동적 import 경로는 정적으로 분석할 수 있게 명시적인 문자열로 작성하는 편이 좋다.

// 분석과 preload가 어려운 형태
const path = `./charts/${chartType}`;
const Chart = dynamic(() => import(path));

// 종류를 명시적으로 매핑
const chartLoaders = {
  line: () => import("./charts/line-chart"),
  bar: () => import("./charts/bar-chart"),
};

Server Component와 Client Component가 섞인 환경에서는 “서버에서 렌더되는 코드”와 “브라우저에 전달되는 JavaScript”를 구분해야 한다. Server Component를 분리했다고 반드시 클라이언트 번들이 줄어드는 것은 아니며, Client Component 경계가 어떤 import 그래프를 끌고 가는지 확인해야 한다. 이 경계는 Next.js Server Component와 Client Component 경계와 연결된다.

버전별 동작 확인

next/dynamic, Server Component, preload의 세부 동작은 Next.js 버전에 따라 달라질 수 있다. 예시를 그대로 적용하기 전에 사용 중인 메이저 버전의 빌드 결과와 공식 문서를 확인한다.

너무 잘게 나누면 생기는 역효과

작은 chunk가 많아지면 초기 다운로드 바이트는 줄 수 있지만 다른 비용이 생긴다.

요청과 스케줄링 비용

HTTP/2와 HTTP/3가 여러 요청을 효율적으로 처리해도 요청이 완전히 공짜가 되는 것은 아니다. 헤더 처리, 우선순위, 연결 경합, 브라우저 스케줄링 비용이 남는다.

로딩 UI의 반복

아이콘, 버튼, 작은 카드까지 각각 lazy loading하면 화면 곳곳에서 fallback이 깜빡인다. 사용자는 하나의 화면이 아니라 조각난 화면을 경험한다.

요청 폭포

부모 chunk를 실행해야 자식 동적 import를 발견할 수 있다면 네트워크가 직렬로 이어진다.

sequenceDiagram
    participant B as 브라우저
    participant S as 서버/CDN

    B->>S: route chunk 요청
    S-->>B: route chunk 응답
    B->>B: 실행 후 chart import 발견
    B->>S: chart chunk 요청
    S-->>B: chart chunk 응답
    B->>B: 실행 후 locale import 발견
    B->>S: locale chunk 요청
    S-->>B: locale chunk 응답

공통 코드 중복

번들러 설정과 import 형태에 따라 여러 비동기 chunk에 같은 유틸리티나 라이브러리가 중복될 수 있다. 작은 소스 파일의 개수만 봐서는 알 수 없고 번들 분석 결과를 확인해야 한다.

캐시 무효화 증가

분할 전략이 자주 변하면 chunk 이름과 의존 관계가 바뀌어 장기 캐시 이점을 잃을 수 있다. 반대로 안정적인 vendor와 라우트 경계는 변경 범위를 제한하는 데 도움이 된다.

컴포넌트 크기는 분할 기준이 아니다

파일이 길다는 이유만으로 lazy loading하지 않는다. 초기 경로에서 반드시 보여야 하는 큰 hero 컴포넌트는 지연하면 오히려 LCP가 나빠질 수 있다. 사용 시점과 실행 비용이 기준이다.

요청 폭포를 줄이는 방법

지연 로딩의 단점은 사용자가 필요로 하기 직전에 로딩을 시작하면 줄일 수 있다.

라우트 prefetch 활용

프레임워크의 링크 컴포넌트는 viewport에 들어온 링크의 다음 라우트를 미리 가져올 수 있다. Next.js의 정확한 prefetch 동작은 라우트의 정적·동적 특성과 설정에 따라 다르므로 Network 탭에서 확인한다.

import Link from "next/link";

export function Navigation() {
  return (
    <Link href="/admin/dashboard">
      관리자 대시보드
    </Link>
  );
}

모든 링크를 무조건 prefetch하면 방문하지 않을 코드와 데이터를 다시 내려받게 된다. 사용 가능성이 높은 경로와 네트워크 비용을 함께 본다.

사용자 의도를 신호로 사용

버튼 hover나 focus 시점에 모듈 import를 먼저 시작할 수 있다.

const loadPreview = () => import("./markdown-preview");

export function PreviewButton({
  onOpen,
}: {
  onOpen: () => void;
}) {
  return (
    <button
      type="button"
      onPointerEnter={loadPreview}
      onFocus={loadPreview}
      onClick={onOpen}
    >
      미리보기 열기
    </button>
  );
}

import()는 같은 모듈을 다시 요청하지 않으므로 실제 클릭 전에 다운로드가 끝날 가능성이 높다. 키보드 사용자를 위해 hover뿐 아니라 focus도 처리한다.

터치 환경에서는 hover 신호가 없고 데이터 절약 모드일 수도 있다. 기능의 중요도와 대상 사용자를 고려해 idle 시점, viewport 진입, 명시적 클릭 중 하나를 선택한다.

서로 필요한 코드는 같은 경계에 둔다

차트 본체와 반드시 필요한 formatter를 각각 순차 lazy loading하지 말고 하나의 기능 chunk에서 함께 import하도록 구성한다. 동시에 필요한 자원을 인위적으로 직렬화하지 않는다.

공통 의존성과 캐시 전략

라우트 A와 B가 모두 큰 차트 라이브러리를 사용하면 선택지가 생긴다.

전략 장점 단점
각 라우트 chunk에 포함 첫 진입 요청 구조가 단순 라이브러리 중복 가능
공통 async chunk로 추출 한 번 받은 뒤 재사용 첫 진입 요청 하나 증가 가능
초기 vendor chunk에 포함 이후 라우트 이동이 빠름 차트를 안 쓰는 사용자도 비용 지불

정답은 사용 패턴에 따라 다르다. 대부분 사용자가 두 라우트를 연속 방문한다면 공통 chunk의 캐시 재사용이 유리하다. 일부 관리자만 차트를 본다면 초기 vendor에 포함하지 않는 편이 낫다.

barrel import도 의존성 범위를 키울 수 있다.

// 많은 컴포넌트를 다시 export하는 진입점
import { DatePicker } from "@/ui";

// 필요한 모듈 경로를 직접 사용
import { DatePicker } from "@/ui/date-picker";

현대 번들러가 tree shaking을 지원하더라도 패키지의 sideEffects 설정, CommonJS 변환, 모듈 구조에 따라 결과가 달라진다. 코딩 규칙만으로 단정하지 말고 분석 보고서에서 실제 포함 모듈을 본다.

캐시를 고려할 때는 chunk의 변경 빈도도 중요하다.

코드와 데이터를 같은 “로딩”으로 묶어 생각하지 않으면 전략이 더 명확해진다.

로딩과 실패 UI도 기능의 일부다

lazy import는 Promise이므로 네트워크 오류나 배포 교체 시 실패할 수 있다. Suspense는 대기 상태를 보여 주지만 거절된 Promise의 오류 UI는 가까운 Error Boundary가 담당해야 한다.

<FeatureErrorBoundary fallback={<ChartLoadError />}>
  <Suspense fallback={<ChartSkeleton />}>
    <AnalyticsChart />
  </Suspense>
</FeatureErrorBoundary>

fallback은 실제 콘텐츠와 비슷한 크기를 가져야 레이아웃 이동이 줄어든다.

function ChartSkeleton() {
  return (
    <div
      className="chart-skeleton"
      role="status"
      aria-label="차트를 불러오는 중"
    />
  );
}
.chart-skeleton {
  min-height: 320px;
  border-radius: 12px;
  background: linear-gradient(
    90deg,
    #f2f2f2,
    #fafafa,
    #f2f2f2
  );
}

오류 화면에는 사용자가 할 수 있는 다음 행동을 제공한다.

다만 모든 ChunkLoadError에서 자동 새로고침하면 반복 루프가 생길 수 있다. 버전 표식이나 session flag로 한 번만 시도하도록 제한하고 원인을 관측한다.

Suspense 경계의 크기

너무 높은 위치에 하나만 두면 작은 기능 하나가 로드될 때 전체 페이지가 fallback으로 바뀐다. 너무 잘게 두면 skeleton이 난무한다. 사용자가 하나의 작업 단위로 인식하는 영역을 경계로 삼는다.

측정 없이 최적화하지 않기

코드 스플리팅 전후에는 같은 조건으로 최소한 다음을 비교한다.

빌드 단계

브라우저 단계

Chrome DevTools에서는 Network, Performance, Coverage를 함께 볼 수 있다. Coverage에서 초기 화면에 사용되지 않은 JavaScript 비율이 크다면 분할 후보를 찾는 단서가 된다. 다만 한 번의 상호작용만 기록한 Coverage로 삭제 여부를 결정하지 않는다.

간단한 예산을 CI에 둘 수도 있다.

{
  "routeBudgets": {
    "/products": {
      "initialJsKb": 250,
      "maxLongTaskMs": 200
    },
    "/admin/dashboard": {
      "initialJsKb": 420,
      "maxLongTaskMs": 300
    }
  }
}

이 숫자는 모든 서비스에 적용되는 기준이 아니라 팀이 정한 회귀 감지선이다. 실제 사용자 기기 분포와 네트워크를 기준으로 조정한다.

로컬의 빠른 Mac과 Wi-Fi만으로 측정하지 않는다. CPU throttling, 느린 모바일 네트워크, 캐시가 비어 있는 첫 방문, 캐시가 채워진 재방문을 나누어 본다. 코드 스플리팅은 이 조건에 따라 결과가 크게 달라진다.

적용 순서와 선택 기준

처음부터 bundler의 chunk 설정을 세밀하게 조정하기보다 다음 순서가 안전하다.

  1. 빌드 분석으로 가장 큰 초기 의존성을 찾는다.
  2. 프레임워크의 라우트 자동 분할이 실제로 적용되는지 확인한다.
  3. 첫 화면에 필요 없는 큰 기능을 라우트 또는 기능 경계로 분리한다.
  4. fallback과 Error Boundary를 함께 설계한다.
  5. 핵심 다음 경로에는 prefetch 또는 사용자 의도 기반 preload를 적용한다.
  6. 실제 기기 조건에서 초기 진입과 이후 탐색을 다시 측정한다.
  7. 중복 chunk와 요청 폭포가 생기면 경계를 합치거나 공통 chunk를 조정한다.
분할 후보

적용 후 검증

마무리

라우트 기반 코드 스플리팅은 사용자가 아직 방문하지 않은 화면의 JavaScript 비용을 뒤로 미루는 가장 이해하기 쉬운 방법이다. 라우트는 제품의 작업 단위이면서 번들 경계, 캐시 경계, 로딩 UI 경계가 될 수 있다.

하지만 chunk가 작다는 사실만으로 성능이 좋아지지는 않는다. 초기 다운로드가 줄어든 대신 이후 화면에서 요청 폭포가 생길 수 있고, 지나치게 작은 경계는 로딩 UI와 실패 지점을 늘린다. 공통 의존성의 캐시 재사용과 실제 사용자의 탐색 순서까지 함께 봐야 한다.

좋은 코드 스플리팅은 JavaScript를 가장 많이 나누는 것이 아니라, 현재 사용자에게 필요하지 않은 실행 비용을 자연스러운 다음 순간으로 옮기는 것이다. 빌드 보고서에서 후보를 찾고, 느린 환경의 브라우저에서 초기 진입과 다음 이동을 함께 측정해야 그 효과를 판단할 수 있다.

관련 노트

참고 자료